Skip to content

Add BackendOption loading to swift bindings (#18855)#18855

Open
metascroy wants to merge 6 commits intopytorch:mainfrom
metascroy:export-D100710833
Open

Add BackendOption loading to swift bindings (#18855)#18855
metascroy wants to merge 6 commits intopytorch:mainfrom
metascroy:export-D100710833

Conversation

@metascroy
Copy link
Copy Markdown
Contributor

@metascroy metascroy commented Apr 13, 2026

Summary:

Add backend options support to the ExecuTorch Swift/ObjC bindings.

The C++ Module class supports LoadBackendOptionsMap for passing per-delegate configuration (e.g. compute unit, thread count, cache directory) at model load time, but this was not exposed through the Swift/ObjC layer. This diff adds:

  • ExecuTorchBackendOption (BackendOption in Swift) — a single key/value configuration entry with boolean, integer, or string value types. Immutable, equatable/hashable, with a human-readable description for debugging.
  • ExecuTorchBackendOptionsMap (BackendOptionsMap in Swift) — a map from backend identifier to [BackendOption]. Built once from a dict; validates input at construction time (rejects integer overflow of the C++ 32-bit storage and strings that would exceed the fixed-size option-key/value buffers). Owns the backing C++ storage that the runtime borrows from, and is retained by the Module for the lifetime of the borrow so lazy load_method calls (triggered by forward) still see a valid map.
  • New load/loadMethod overloads on ExecuTorchModule that accept a BackendOptionsMap, plus idiomatic Swift wrappers Module.load(:verification:) and Module.load(:options:).

Current CoreML backend options are documented here: https://github.com/pytorch/executorch/blob/main/backends/apple/coreml/runtime/include/coreml_backend/coreml_backend_options.h

Swift usage:

let module = Module(filePath: "model.pte")
let options = try BackendOptionsMap(options: [
  "CoreMLBackend": [
    BackendOption("compute_unit", "cpu_and_gpu"),
    BackendOption("_use_new_cache", true),
  ]
])
try module.load(options)

Differential Revision: D100710833

@metascroy metascroy requested a review from shoumikhin as a code owner April 13, 2026 22:47
@pytorch-bot
Copy link
Copy Markdown

pytorch-bot Bot commented Apr 13, 2026

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/18855

Note: Links to docs will display an error until the docs builds have been completed.

❌ 2 New Failures, 3 Unrelated Failures

As of commit 3f8d2ce with merge base e84a418 (image):

NEW FAILURES - The following jobs have failed:

  • Apple / build-benchmark-app / macos-job (gh)
    /Users/runner/work/executorch/executorch/pytorch/executorch/extension/apple/ExecuTorch/__tests__/ObjC/ModuleTestObjC.m:100:23: error: initializer element is not a compile-time constant
  • Apple / build-frameworks-ios / macos-job (gh)
    /Users/runner/work/executorch/executorch/pytorch/executorch/extension/apple/ExecuTorch/__tests__/ObjC/ModuleTestObjC.m:100:23: error: initializer element is not a compile-time constant

BROKEN TRUNK - The following jobs failed but were present on the merge base:

👉 Rebase onto the `viable/strict` branch to avoid these failures

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Apr 13, 2026
@meta-codesync
Copy link
Copy Markdown
Contributor

meta-codesync Bot commented Apr 13, 2026

@metascroy has exported this pull request. If you are a Meta employee, you can view the originating Diff in D100710833.

@github-actions
Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@meta-codesync meta-codesync Bot changed the title Add BackendOption loading to swift bindings Add BackendOption loading to swift bindings (#18855) Apr 14, 2026
metascroy added a commit to metascroy/executorch that referenced this pull request Apr 14, 2026
Summary:


Add backend options support to the ExecuTorch Swift/ObjC bindings.

The C++ Module class supports LoadBackendOptionsMap for passing per-delegate configuration (e.g. compute unit, thread count, cache directory) at model load time, but this was not exposed through the Swift/ObjC layer. This diff adds:

A new ExecuTorchBackendOption ObjC class (BackendOption in Swift) representing a single key-value configuration option with support for boolean, integer, and string value types.
New load and loadMethod overloads on ExecuTorchModule that accept a dictionary mapping backend identifiers to arrays of backend options.
Swift extensions providing idiomatic load(backendOptions:verification:) and load(_:backendOptions:) APIs on Module.

```
let module = Module(filePath: "model.pte")
try module.load(backendOptions: [
  "CoreMLBackend": [
    BackendOption("compute_unit", "cpu_and_gpu"),
    BackendOption("num_threads", 4),
    BackendOption("use_cache", true),
  ]
])
```

Differential Revision: D100710833
metascroy added a commit to metascroy/executorch that referenced this pull request Apr 14, 2026
Summary:


Add backend options support to the ExecuTorch Swift/ObjC bindings.

The C++ Module class supports LoadBackendOptionsMap for passing per-delegate configuration (e.g. compute unit, thread count, cache directory) at model load time, but this was not exposed through the Swift/ObjC layer. This diff adds:

A new ExecuTorchBackendOption ObjC class (BackendOption in Swift) representing a single key-value configuration option with support for boolean, integer, and string value types.
New load and loadMethod overloads on ExecuTorchModule that accept a dictionary mapping backend identifiers to arrays of backend options.
Swift extensions providing idiomatic load(backendOptions:verification:) and load(_:backendOptions:) APIs on Module.

Current available options for CoreML backend are here: https://github.com/pytorch/executorch/blob/main/backends/apple/coreml/runtime/include/coreml_backend/coreml_backend_options.h

```
let module = Module(filePath: "model.pte")
try module.load(backendOptions: [
  "CoreMLBackend": [
    BackendOption("compute_unit", "cpu_and_gpu"),
    BackendOption("_use_new_cache", true),
  ]
])
```

Differential Revision: D100710833
@metascroy metascroy requested a review from GregoryComer as a code owner April 30, 2026 00:13
@meta-codesync
Copy link
Copy Markdown
Contributor

meta-codesync Bot commented Apr 30, 2026

@metascroy has imported this pull request. If you are a Meta employee, you can view this in D100710833.

Comment thread extension/apple/ExecuTorch/__tests__/ModuleTest.swift
}

func testLoadWithBackendOptions() throws {
guard let modelPath = resourceBundle.path(forResource: "add", ofType: "pte") else {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this pass? Where is add genrated?

XCTAssertEqual(outputs?.first?.tensor(), Tensor([Float(2)]))
}

// Regression test: when load(_:BackendOptionsMap) is followed by a lazy
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is too specific to previous failing beahvior. Either fix old tests with add fiture or just do CoreML fixture

Comment thread extension/apple/ExecuTorch/Exported/ExecuTorchBackendOptionsMap.h
// INVARIANT: this ivar is only ever overwritten with another non-nil
// BackendOptionsMap, and never reset to nil while `_module` is alive.
// Resetting it to nil would release the C++ map underneath `_module`'s
// borrowed pointer and re-introduce the dangling-pointer bug class this
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This not specifcy "this refactor" in comment

@metascroy
Copy link
Copy Markdown
Contributor Author

@claude review this code

@claude
Copy link
Copy Markdown

claude Bot commented Apr 30, 2026

Claude finished @metascroy's task in 2m 9s —— View job


Code Review: Add BackendOption loading to Swift bindings

  • Read and analyze all changed files
  • Review ObjC headers and implementations
  • Review Swift extensions and tests
  • Review Package.swift and build script changes
  • Check for issues flagged in existing review comments
  • Post comprehensive review feedback

Overall Assessment

This is a well-structured addition that properly bridges the C++ LoadBackendOptionsMap API to Swift/ObjC. The lifetime management (retaining BackendOptionsMap via ARC so the C++ borrowed pointer stays valid) is the key design decision and it's handled correctly. The test coverage is thorough, especially the regression tests for the dangling-pointer scenario.


Issues Flagged in Existing Review Comments

I agree with the self-review feedback from @metascroy:

1. requireFixture message is too specific (ModuleTest.swift:31)

The error message hardcodes generate_coreml_test_models.py but the helper is generic. Either make the message parametric, or just say "fixture not found":

let message = "\(name).\(type) not bundled."

Fix this →

2. testLoadWithBackendOptions uses add.pte — does this pass? (ModuleTest.swift:267)

The test at line 266 uses the add.pte fixture (which has zero delegates) with a "SomeBackend" option. This will pass because the runtime simply ignores backend options for backends that aren't present in the model — the options map is consulted per-delegate during load_method. So the test exercises the API path without triggering a real delegate lookup. This is fine for API smoke-testing but the comment or test name could clarify it's testing the "options are accepted without error" path, not that options are applied.

3. Long regression comment before testLoadWithBackendOptionsThenExecuteOnCoreMLDelegatedModel (ModuleTest.swift:327-339)

The 10-line comment block is very detailed about internal crash mechanics. Consider trimming to 2-3 lines explaining what scenario this guards against, and leave the full debugging narrative in the commit message. Per CLAUDE.md: "Minimal comments; code should be self-documenting."

Fix this →

4. initWithOptions vs mapWithOptions (ExecuTorchBackendOptionsMap.h:54)

Having both is standard ObjC convention (designated initializer + class factory), but the factory method doesn't add value here since it's a one-liner that calls the init. If you want to keep the ObjC pattern, it's fine — but if this is primarily consumed via Swift (where BackendOptionsMap(options:) maps to the init), the factory could be removed to reduce API surface.

5. "this refactor" in comment (ExecuTorchModule.mm:263-264)

The comment says "...the dangling-pointer bug class this refactor was created to eliminate." The phrase "this refactor" references a temporal event and will become meaningless over time. Rewrite to state the invariant without referencing the PR:

// Resetting to nil would release the C++ map underneath _module's
// borrowed pointer, causing a dangling-pointer crash.

Fix this →


Additional Findings

6. Integer range check is a no-op on LP64 (ExecuTorchBackendOptionsMap.mm:75-77)

if (opt.intValue < INT_MIN || opt.intValue > INT_MAX) {
  return Error::InvalidArgument;
}

On 64-bit Apple platforms, NSInteger is int64_t and INT_MIN/INT_MAX are 32-bit constants, so this check works correctly. However, on a hypothetical 32-bit build, NSInteger == int32_t == INT_MIN..INT_MAX, making the condition always false (dead code). The test testBackendOptionsMapValidation passes Int(Int32.max) + 1 which requires 64-bit Int. This is fine for Apple's current 64-bit-only platforms, but worth noting.

7. BackendOptionsMap Swift API mismatch with PR description (ExecuTorch+Module.swift:62-67)

The PR description shows:

try module.load(backendOptions: [
  "CoreMLBackend": [BackendOption("compute_unit", "cpu_and_gpu")]
])

But the actual Swift API requires a two-step construction:

let options = try BackendOptionsMap(options: [...])
try module.load(options)

This is arguably better (construct once, reuse many times), but the PR description is misleading. Consider adding a convenience load(backendOptions:) overload that accepts the raw dictionary directly, or updating the PR description.

8. loadMethod:options: does NOT retain options (ExecuTorchModule.mm:375-395)

The comment says the C++ Module::load_method "uses it within the same call stack and does not store it." This should be verified — if the C++ Module's load_method stores the LoadBackendOptionsMap* for deferred use (as load() does), this would be a dangling-pointer bug. Looking at the call: _module->load_method(...) with the map pointer — if the method is loaded synchronously and doesn't store the map, this is safe. But it's a subtle invariant that could break if the C++ API changes.

9. Missing NS_ASSUME_NONNULL_END visual balance (ExecuTorchBackendOption.mm)

Minor: The .mm file doesn't use NS_ASSUME_NONNULL_BEGIN/END unlike the header. This is fine (it's an implementation file), just noting for consistency.

10. Test testBackendOptionsMapReusedAcrossModules depends on CoreML fixture (ModuleTest.swift:433)

This test uses requireFixture("add_coreml", ...) which will be skipped on local dev machines without CoreML python deps. The non-CoreML variant of this test (reuse across modules with add.pte) isn't present. Consider adding one with the plain add.pte fixture if the goal is to test ARC-based reuse regardless of backend.


Code Quality

  • The ObjC class design is clean: immutable value types, proper isEqual:/hash, defensive description overrides.
  • The C++ bridge in buildBackendOptionsMap() correctly handles lifetime of the std::vector<std::vector<BackendOption>> storage backing the Spans.
  • The Package.swift conditional resource inclusion is pragmatic for the CI-only fixture.
  • The .gitignore files and symlink strategy for ObjC test resources are well thought out.

Summary

The implementation is solid. The main actionable items from the self-review are straightforward comment/message fixes. The most architecturally important thing to verify is point #8 — ensuring load_method's C++ contract truly doesn't store the map pointer.

Summary:


Add backend options support to the ExecuTorch Swift/ObjC bindings.

The C++ Module class supports LoadBackendOptionsMap for passing per-delegate configuration (e.g. compute unit, thread count, cache directory) at model load time, but this was not exposed through the Swift/ObjC layer. This diff adds:

A new ExecuTorchBackendOption ObjC class (BackendOption in Swift) representing a single key-value configuration option with support for boolean, integer, and string value types.
New load and loadMethod overloads on ExecuTorchModule that accept a dictionary mapping backend identifiers to arrays of backend options.
Swift extensions providing idiomatic load(backendOptions:verification:) and load(_:backendOptions:) APIs on Module.

Current available options for CoreML backend are here: https://github.com/pytorch/executorch/blob/main/backends/apple/coreml/runtime/include/coreml_backend/coreml_backend_options.h

```
let module = Module(filePath: "model.pte")
try module.load(backendOptions: [
  "CoreMLBackend": [
    BackendOption("compute_unit", "cpu_and_gpu"),
    BackendOption("_use_new_cache", true),
  ]
])
```

Differential Revision: D100710833
@metascroy
Copy link
Copy Markdown
Contributor Author

@claude review this code

@claude
Copy link
Copy Markdown

claude Bot commented Apr 30, 2026

Claude finished @metascroy's task in 2m 34s —— View job


Code Review: Add BackendOption loading to Swift bindings

  • Read and analyze all changed files
  • Review ObjC headers and implementations
  • Review Swift extensions and tests
  • Review Package.swift and build script changes
  • Check for issues flagged in existing review comments
  • Post comprehensive review feedback

Overall Assessment

This is a well-structured addition that correctly bridges the C++ LoadBackendOptionsMap API to Swift/ObjC. The key design decision — retaining BackendOptionsMap via ARC so the C++ Module::backend_options_ raw pointer (stored at module.cpp:195) stays valid through lazy load_method calls (consumed at module.cpp:363) — is sound and well-documented.

The code quality is high: immutable value types, proper isEqual:/hash, defensive description overrides, and thorough test coverage including the dangling-pointer regression path.


Issues from Self-Review Comments

1. requireFixture error message — Already fixed. The current code at ModuleTest.swift:30 uses the generic "\(name).\(type) not bundled." message. No action needed.

2. mapWithOptions: factory method (ExecuTorchBackendOptionsMap.h:61-63)

The class factory +mapWithOptions:error: is a standard ObjC pattern but since this class is primarily consumed via Swift (where BackendOptionsMap(options:) maps to the designated init), it adds API surface without adding value. Fine to keep for ObjC convention, but could be removed if you want a leaner API.

3. _loadedBackendOptions ivar comment (ExecuTorchModule.mm:255-269)

The comment block is well-written and documents the key invariant clearly. However, the original review flagged "this refactor" language — I see it's already been removed in the current version. The current text is clean.


Additional Findings

4. Potential issue: loadMethod:options: retains options but load_method stores nothing

At ExecuTorchModule.mm:384, _loadedBackendOptions = options overwrites the previously retained options. This is fine because:

  • C++ Module::load_method() at module.cpp:357 uses the passed backend_options pointer only within the call scope to initialize the method.
  • Once the method is loaded, the pointer is no longer needed.

But there's a subtle edge case: if a user calls loadWithOptions: (which stores backend_options_ for future lazy load_method calls) and then later calls loadMethod:options: with different options, the _loadedBackendOptions assignment at line 384 would release the first options object — but _module->backend_options_ still points at the first object's C++ storage. This would be a use-after-free if another method is lazily loaded later via forward().

Scenario:

[module loadWithOptions:optionsA error:&err];  // _module->backend_options_ = &optionsA.cppMap
// No methods loaded yet (lazy)
[module loadMethod:@"preprocess" options:optionsB error:&err];
// _loadedBackendOptions = optionsB → releases optionsA
// _module->backend_options_ still points at freed optionsA storage!
[module forward:...];  // lazy loads "forward" via backend_options_ → dangling pointer

Recommendation: Either:

  • Store both (e.g., accumulate into an array), or
  • Assert/document that loadMethod:options: must not be called after loadWithOptions: on the same module, or
  • Have loadMethod:options: NOT overwrite _loadedBackendOptions (since the C++ load_method path consumes it synchronously and doesn't store it).

The simplest fix is option 3 — don't assign _loadedBackendOptions = options in loadMethod:options: since load_method doesn't store the pointer:

- (BOOL)loadMethod:(NSString *)methodName
           options:(ExecuTorchBackendOptionsMap *)options
             error:(NSError **)error {
  NSParameterAssert(options);
  // load_method consumes backend_options synchronously; no retention needed.
  const auto errorCode = _module->load_method(methodName.UTF8String,
                                               /*planned_memory=*/nullptr,
                                               /*event_tracer=*/nullptr,
                                               [options cppMap]);
  ...
}

But then the caller must ensure options stays alive for the duration of the call (which ARC guarantees since it's a parameter). So this is actually safe and simpler.

Fix this →

5. Integer range check is a no-op on 32-bit (ExecuTorchBackendOptionsMap.mm:75-77)

if (opt.intValue < INT_MIN || opt.intValue > INT_MAX) {
  return Error::InvalidArgument;
}

On 64-bit Apple platforms (the only targets currently supported), NSInteger is int64_t so this works correctly. On a hypothetical 32-bit build, NSInteger == int32_t and the check becomes tautologically false. Not a real issue today, but worth a brief note if 32-bit ever matters.

6. std::move(map) may not do what's expected (ExecuTorchBackendOptionsMap.mm:141)

_map = std::move(map);

If LoadBackendOptionsMap internally stores Span<BackendOption> objects that point into storage's vectors, and _storage = std::move(storage) runs on line 140 first, the spans inside map/_map still point at the (now moved) _storage vectors' heap buffers. This is correct because std::vector move preserves the data pointer. But if LoadBackendOptionsMap's move constructor does anything beyond a shallow member copy (e.g., recalculates span pointers), this could break. The existing comment at lines 36-40 already explains this — just flagging that this is a fragile invariant worth a unit test (which testLoadWithBackendOptionsThenExecuteOnCoreMLDelegatedModel effectively provides).

7. Missing test for loadMethod:options: Swift path

The Swift extension exposes module.load("forward", options: opts) but there's no test exercising this path. Consider adding a brief test that calls module.load("forward", options: opts) followed by module.forward(...) to verify the loadMethod:options: ObjC bridge works end-to-end.

8. testBackendOptionsMapValidation depends on 64-bit Int (ModuleTest.swift:342)

BackendOption("too_big", Int(Int32.max) + 1)

This would overflow on a 32-bit platform. Fine for Apple's current 64-bit-only targets, just noting for completeness.


Minor Nits

  • ExecuTorchBackendOption.mm:80: The (null) case in the string description is unreachable given the init enforces non-nil stringValue, but the defensive coding is fine.
  • generate_coreml_test_models.py: Clean and minimal. The os.path.dirname(__file__) approach is good for finding the output directory.
  • The .gitignore files and symlink strategy for ObjC test resources are well thought out and avoid committing generated artifacts.

Summary

The implementation is solid. The one actionable concern is point #4 — the loadMethod:options: assignment to _loadedBackendOptions could cause a dangling pointer if called after loadWithOptions: with different options. The fix is straightforward (remove the assignment since load_method doesn't store the pointer). Everything else is clean and well-considered.


@metascroy
Copy link
Copy Markdown
Contributor Author

@claude review this PR

@claude
Copy link
Copy Markdown

claude Bot commented Apr 30, 2026

Claude finished @metascroy's task in 2m 32s —— View job


Code Review: Add BackendOption loading to Swift bindings

  • Read and analyze all changed files
  • Review ObjC headers and implementations
  • Review Swift extensions and tests
  • Review Package.swift and build script changes
  • Post comprehensive review feedback

Overall Assessment

This is a well-designed addition. The key architectural decision — retaining BackendOptionsMap via ARC so the C++ Module::backend_options_ raw pointer (stored at module.cpp:195) remains valid through lazy load_method calls (consumed at module.cpp:363) — is correct and thoroughly tested, including the testMixedLoadWithOptionsAndLoadMethodWithOptionsOnMultiMethodModel regression test with weak reference tracking.

The code quality is high: immutable value types, proper isEqual:/hash, validation at construction time, and the ObjC/Swift layering is clean.


Critical Issue

loadMethod:options: must not overwrite _loadedBackendOptions — Already handled correctly.

I verified the implementation at ExecuTorchModule.mm:374-396. The comment at line 378-385 explicitly documents why _loadedBackendOptions is NOT assigned here, avoiding the use-after-free scenario where loadWithOptions: installs optionsA into _module->backend_options_, and a subsequent loadMethod:options: with optionsB would release optionsA's storage. This is the correct design — the testMixedLoadWithOptionsAndLoadMethodWithOptionsOnMultiMethodModel test validates it deterministically via the weakA assertion.


Suggestions

1. mapWithOptions: factory method adds little value (ExecuTorchBackendOptionsMap.h:61-63)

Since Swift consumers use BackendOptionsMap(options:) (which maps to the designated init), the class factory is only useful for ObjC callers who prefer +mapWith style. It's used in the ObjC tests, so it earns its keep there. No change needed, just noting it's a stylistic choice.

2. Move semantics of _storage and _map deserve a targeted unit test (ExecuTorchBackendOptionsMap.mm:144-152)

The comment at lines 145-151 correctly explains why _storage = std::move(storage) must come before _map = std::move(map) — the Spans inside map point into the inner vectors' heap buffers, and std::vector move preserves data() pointers. This is fragile: if LoadBackendOptionsMap's move constructor ever recomputes span pointers, this would break silently. The end-to-end CoreML test exercises this path (as the comment notes), but a targeted test that validates the pointer stability more directly would add confidence.

3. buildBackendOptionsMap could return a more specific error for different failure modes (ExecuTorchBackendOptionsMap.mm:41-114)

Currently all validation failures return the same Error::InvalidArgument. The caller maps this to a single NSError. Consider differentiating (e.g., via userInfo) to distinguish "integer overflow" vs "key too long" vs "value too long" so callers can provide actionable error messages. Low priority — the current behavior is acceptable for v1.

4. The options property returns a snapshot, not the live backing (ExecuTorchBackendOptionsMap.mm:171-173)

This is the correct choice for an immutable API, but worth noting that the snapshot is built eagerly at init time (lines 158-162). For large option dictionaries this doubles memory briefly during construction. Not a real concern for the typical 1-5 options per backend, but good to be aware of.

5. Test coverage gap: no non-CoreML test for load(options) (ModuleTest.swift:266-286)

All load(options) tests that actually exercise the lazy load path depend on CoreML fixtures (add_coreml.pte, add_mul_coreml.pte). The non-delegated add.pte model would silently ignore backend options (since it has no delegates), so it can't test the option lookup. This is acceptable — the runtime simply ignores options for absent backends — but it means local developers without CoreML deps will get all backend-option tests skipped. Consider adding a brief test with add.pte that verifies load(options) doesn't error even when no matching backend exists, to exercise the API path without CoreML:

func testLoadWithBackendOptionsOnNonDelegatedModel() throws {
    guard let modelPath = resourceBundle.path(forResource: "add", ofType: "pte") else {
        XCTFail("Couldn't find the model file")
        return
    }
    let module = Module(filePath: modelPath)
    let options = try BackendOptionsMap(options: [
        "SomeBackend": [BackendOption("key", "value")]
    ])
    XCTAssertNoThrow(try module.load(options))
    let outputs: [Value] = try module.forward(Tensor<Float>([1]), Tensor<Float>([2]))
    XCTAssertEqual(outputs.first?.tensor(), Tensor([Float(3)]))
}

Add this test →

6. Package.swift uses Foundation import for FileManager (Package.swift:21)

This is valid but somewhat unusual for a Package.swift manifest. SwiftPM processes the manifest in a sandbox, and FileManager.default.fileExists(atPath:) relies on the path being relative to the package root (which it is, since SwiftPM evaluates manifests from the repo root). This works correctly.

7. ObjC test requireFixture and Swift test requireFixture have slightly different semantics

The Swift version (ModuleTest.swift:26-42) throws XCTSkip locally or a plain NSError in CI. The ObjC version (ModuleTestObjC.m:32-46) calls XCTFail in CI (which does not throw — the test continues!) and then returns nil. The ObjC caller pattern if (!modelPath) return; handles this correctly, but it's a subtle difference from the Swift version where throw halts execution. Both are correct, just different idioms for their respective languages.


Minor Nits

  • ExecuTorchBackendOption.mm:80: The (null) fallback in the string description case is unreachable since the init enforces non-nil stringValue. Defensive coding is fine here.
  • ExecuTorchBackendOption.mm:66-67: strncpy with the exact kMaxOptionKeyLength - 1 bound is correct (null terminator set on line 67). The strlen check on line 63 guarantees no truncation.
  • generate_coreml_test_models.py: Clean and minimal. The multi-method model generation using to_edge_transform_and_lower({"forward": ep_add, "mul": ep_mul}, ...) is a neat pattern.

Summary

The implementation is solid and production-ready. The lifetime management is the hardest part of this design and it's handled correctly with proper documentation and regression tests. The main suggestion is adding a non-CoreML smoke test so local developers get at least one backend-options API test running without CoreML python deps.

Copy link
Copy Markdown
Contributor

@shoumikhin shoumikhin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed in detail with focus on real correctness/safety issues over style. Inline comments cover the highest-ROI items:

  • Lifetime: loadWithOptions: rollback + verification request for the load_method retain claim
  • Validation: duplicate keys and empty keys silently accepted; all failures collapse to one error code
  • Swift API: load(_:) autocomplete collision with the existing String overload

Solid foundation otherwise — the _loadedBackendOptions lifetime model is sound, length math is correct (>= against buffer size catches the NUL-overflow case), the two-phase-commit init is clean, conventions match the rest of the bindings.

Test gaps worth adding (not inline since they're additions, not changes to existing lines):

  • BackendOptionsMap.options snapshot accessor is never read in any test. The "deep-immutable snapshot" promise is unverified. Add: build with NSMutableDictionary+NSMutableArray, mutate after init, assert .options unchanged.
  • Boundary lengths exactly at the limit never tested. Tests use 256 / 4096 (well over). Add kMaxOptionKeyLength - 1 (max valid) and kMaxOptionKeyLength (first invalid) to pin the off-by-one math at BackendOptionsMap.mm:63.
  • Multiple backends in one map never tested — every test uses one backend key. Add ["CoreMLBackend": [...], "XNNPACK": [...]] to exercise the outer loop.
  • ObjC loadMethod:options:error: has no test (only Swift covers it).
  • Validation error code identity never inspected — tests assert "throws" but never check the code is .invalidArgument, so a buggy code mapping would still pass.

Skipping: Sendable conformance (forward-looking, not a current bug), Double overload binding (theoretical), various style nits (debugDescription, NSCopying, designated-init markers, header/.mm initializer asymmetry) — happy to expand on any if useful, but didn't want to drown the signal.

// valid for the lifetime of any methods loaded with these options.
// (Methods load lazily during forward(), so the borrow may outlive this
// call.) See ExecuTorchBackendOptionsMap.h for the lifetime contract.
_loadedBackendOptions = options;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_loadedBackendOptions = options happens before the C++ load call, with no rollback if _module->load(...) returns non-Ok.

If a previous successful loadWithOptions: had installed a different map and the C++ Module retained internal references to the old map's storage on the failed reload path, that old map is already released by the time you observe the error here.

Suggest: assign only after the load succeeds, or keep both refs until the C++ call returns Ok.

// but the C++ Module's `backend_options_` raw pointer (set by
// -loadWithOptions:) would still reference that released map's
// storage — a use-after-free on the next lazy load_method.
const auto errorCode = _module->load_method(methodName.UTF8String,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "consumes synchronously, doesn't cache" claim about Module::load_method is the load-bearing invariant for the no-retain decision here. The reasoning is correct in the comment above (lines 378–385), but it's worth pinning explicitly.

Could you cite the specific line/range in module.cpp where load_method consumes the borrow? That way a future refactor of load_method can't silently break this without a test failure. Adding an ObjC test that creates options, calls loadMethod:options:, then nils the local options reference, then calls executeMethod: would also lock the contract behaviorally.

break;
}
}
opts.push_back(bo);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two real validation gaps in this loop:

  1. Duplicate keys within a single backend (e.g. [BackendOption("k", 1), BackendOption("k", 2)]) are silently accepted — both get pushed into opts, and the C++ set_option resolves to last-write-wins (or undefined depending on backend). Suggest a std::unordered_set<std::string> of seen keys per backend, returning Error::InvalidArgument on duplicate.
  2. Empty key "" passes the length check at line 63 (strlen("") = 0 < 64). Almost certainly a caller bug. Reject if strlen(keyCStr) == 0.

Same applies to the empty backend identifier "" at line 48 — currently passes through to C++ set_options.

const auto buildError = buildBackendOptionsMap(options, storage, map);
if (buildError != Error::Ok) {
if (error) {
*error = ExecuTorchErrorWithCode((ExecuTorchErrorCode)buildError);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All validation failures collapse to a single ExecuTorchErrorCode.invalidArgument (raised from lines 49, 58, 64, 80, 87, 91). Callers can't distinguish "key too long" vs "value too long" vs "int overflow" vs "non-UTF8 string" vs (if added) "duplicate key."

The test at ModuleTest.swift:339-359 shows this in practice: just XCTAssertThrowsError, no error inspection — because there's nothing distinguishable to check.

Suggest one of:

  • Richer NSError userInfo (include the offending key/value name + reason in localizedDescription)
  • Distinct NS_ERROR_ENUM codes per failure mode

This is the highest-ROI improvement for API users.

/// `try BackendOptionsMap(["CoreMLBackend": [BackendOption("compute_unit", "cpu_and_gpu")]])`.
/// - verification: The verification level to apply when loading the program.
/// - Throws: An error if loading fails.
func load(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module.load(_ options: BackendOptionsMap, verification:) here, plus the existing loadMethod:error: (bridged to Swift as load(_:) at ExecuTorchModule.h:233), produce an autocomplete collision: module.load(someMap) and module.load("forward") both compile cleanly with no first-positional label hint.

Suggest renaming the first label: load(options:) and load(_ method:, options:) (i.e. add the label to options: here and on the sibling at line 76). One keystroke at the call site, eliminates a real ambiguity for anyone using autocomplete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ciflow/trunk CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. fb-exported meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants